// This work is licensed under Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0)
// https://creativecommons.org/licenses/by-nc-sa/4.0/
// © AlgoNorth
//@version=6
indicator("Liquidity Contour Map [AlgoNorth]", "Liq Contour Map [AlgoNorth]", overlay = true, max_polylines_count = 100, max_bars_back = 5000, behind_chart = true)

// ───────────────────────────── Inputs ─────────────────────────────
const string G_P = "Pools"
const string G_E = "Score"
const string G_M = "Map"
const string G_L = "Look"
const string G_C = "Callouts"
const string G_H = "History stats"
const string G_S = "Style"
const string G_T = "Panel"

int    pivLen    = input.int(5, "Swing length (bars)", minval = 2, maxval = 30, group = G_P, tooltip = "Bars on each side that confirm a swing high / low. A pool appears once its swing is confirmed, this many bars after the swing candle, and its zone and tag are drawn back to that candle.")
float  eqTol     = input.float(0.25, "Equal high / low tolerance (ATR)", minval = 0.0, maxval = 2.0, step = 0.05, group = G_P, tooltip = "A new swing within this distance of a live pool on the same side merges into the nearest such pool (EQH / EQL) instead of starting its own. The pool keeps its first swing's price and candle, so the tag and stem stay with the original swing. Each extra touch raises the height, up to the 4th touch.")
int    maxPools  = input.int(60, "Max pools tracked", minval = 10, maxval = 120, group = G_P, tooltip = "Pools held in memory. A swept pool is dropped once its zone has left the map, and any pool older than 4,900 bars is dropped. When the list is full, the live pool furthest beyond the relevance band goes first, then the oldest live pool, then (only when no other pool is live) the oldest swept one. A dropped pool's open stats window still runs to its end and is counted.")
float  relBand   = input.float(3.0, "Relevance band (ATR)", minval = 0.0, maxval = 10.0, step = 0.5, group = G_P, tooltip = "Pools further than this beyond the map's high / low are not drawn or tagged, and live ones open no new windows for the history stats.")

int    riseLen   = input.int(14, "Bars to full height", minval = 2, maxval = 200, group = G_E, tooltip = "A pool's score builds from zero at its swing candle to full height this many bars later. A pool only appears once its swing is confirmed, Swing length bars later, so set this above Swing length for the build-up to show.")
float  erode     = input.float(0.35, "Swept pool height (×)", minval = 0.0, maxval = 1.0, step = 0.05, group = G_E, tooltip = "Height multiplier once price has crossed the pool, gaps included. 0 hides swept zones.")
bool   sweptGry  = input.bool(true, "Grey swept zones", group = G_E, tooltip = "Draws swept zones in grey, so levels price has already crossed read as spent at a glance. Live zones keep the score colours. Off: swept zones use the same colours, only lower.")
int    capLen    = input.int(8, "Swept run-on (bars)", minval = 1, maxval = 60, group = G_E, tooltip = "Bars a swept zone runs on past the sweep before it closes off. A swept zone's score is frozen at the sweep, so its colour never climbs again. Set at or below Extend right, its drawn height is frozen too; above it, a short zone can fill out a little as it lengthens to its end.")
bool   useVol    = input.bool(true, "Scale by swing volume", group = G_E, tooltip = "Scales each pool by its swing candle's volume against the average of the 50 bars before it, clamped to 0.5–2.5×: heavy swings stand taller, quiet ones lower. Uses the chart's own volume. With no volume data, or with this off, every pool counts as average, and the tag's hover says so.")

int    lookback  = input.int(150, "Map width (bars)", minval = 50, maxval = 480, group = G_M, tooltip = "How far back the map reaches. It also sets the price range zone heights scale to, the nose length, and how long a swept zone stays on the map.")
float  hPct      = input.float(9.0, "Max zone half-height (% of map range)", minval = 2.0, maxval = 30.0, step = 0.5, group = G_M, tooltip = "The furthest a zone reaches above and below its pool's price, as a share of the price range across the map. Zones with close neighbours in time are trimmed below this, and zones too short for full noses are drawn proportionally smaller.")
float  gapK      = input.float(0.5, "Min zone gap (half-heights)", minval = 0.25, maxval = 3.0, step = 0.05, group = G_M, tooltip = "Zones that share bars are trimmed to half the gap between them, so no two touch and close levels stay on the map side by side. Only when two pools sit closer than this, too close for two clean zones, is the shorter one hidden; a swept pool competes at its pre-sweep height.")
int    nLevels   = input.int(14, "Contour levels (max)", minval = 4, maxval = 14, group = G_M, tooltip = "Contour rings per zone. If drawing them all would pass TradingView's 100-polyline limit or the script's point cap, the most contours that fit are drawn, evenly spaced, keeping the lowest and the top whenever two or more fit. The panel's settings row shows how many were drawn; the polylines row shows how many more all of them would need (short).")
float  lowPct    = input.float(5.0, "Lowest contour (% of peak)", minval = 1.0, maxval = 40.0, step = 1.0, group = G_M, tooltip = "Height of the outermost ring, as a share of the tallest zone within the map's high–low range (taller pools further out top out at the top colour). A pool whose own peak is below this share is not drawn or tagged.")
float  topPct    = input.float(90.0, "Top contour (% of peak)", minval = 50.0, maxval = 99.0, step = 1.0, group = G_M, tooltip = "Height of the innermost ring. Contours are spaced evenly between the lowest and top levels, and colour follows the score.")
int    extR      = input.int(8, "Extend right (bars)", minval = 0, maxval = 50, group = G_M, tooltip = "Zones run up to this many bars past the last candle (a swept zone ends Swept run-on bars after its sweep). Visual headroom only: heights are measured up to the last closed bar, and this is not a projection.")
float  roundK    = input.float(1.0, "Nose roundness", minval = 0.3, maxval = 3.0, step = 0.1, group = G_M, tooltip = "Size of each zone's rounded nose. Each ring's nose is scaled to its own width, down to a two-bar minimum, so the rings nest like contour lines; half-height and map width scale it too. Noses are measured in bars, so how round they look depends on zoom and pane shape: raise this if they look squashed, lower it if they look stretched.")

string look      = input.string("Flat contours", "Look", options = ["Flat contours", "3D contours"], group = G_L, tooltip = "Flat contours: clean concentric bands, each ring its own outline. 3D contours: lit ring edges and slope shadows. The settings marked 3D apply to that look only.")
bool   edgeOn    = input.bool(true, "3D: lit ring edges", group = G_L, tooltip = "A light edge along every ring, lit from above. The lower edge is half as wide.")
float  edgeW     = input.float(0.35, "3D: edge width (ring gaps)", minval = 0.05, maxval = 1.0, step = 0.01, group = G_L, tooltip = "Edge thickness as a share of the gap between neighbouring rings.")
bool   shadeOn   = input.bool(true, "3D: slope shading", group = G_L, tooltip = "Each ring casts a shadow onto the band below it, so the lower side of every zone reads as the shaded side. Shadows deepen towards the base.")
float  shadeK    = input.float(1.0, "3D: shading strength", minval = 0.05, maxval = 1.5, step = 0.05, group = G_L, tooltip = "Opacity of the slope shadows. They also fade with contour transparency.")
bool   groundOn  = input.bool(true, "3D: ground shadow", group = G_L, tooltip = "A soft shadow under the lowest ring of each zone. It reads on light and mid-grey backgrounds and barely shows on near-black ones.")

bool   showCall  = input.bool(true, "Show callouts", group = G_C, tooltip = "Tags up to Max callouts zones whose swing candle is on the map, each joined to that candle by a dotted stem. Line 1: pool type and price. Line 2 on live pools: a 10-block meter and % showing the share of past windows on same-side pools in this pool's ATR band that price crossed within the horizon, over the last Window bars. A past frequency for that band, not a forecast for this level, and it changes when price moves the pool into another band. On swept pools line 2 says when it was swept. Hover a tag for the sample size, time to sweep (among those windows that were crossed, the bars within which half were; shown once the band has the minimum sample and at least 10 were crossed), band, distance, score, volume and age.")
int    nCall     = input.int(6, "Max callouts", minval = 1, maxval = 12, group = G_C, tooltip = "Live zones are tagged first, tallest first, then swept ones. Each tag's stem uses one of the 100 polylines, which comes out of the map budget.")
string tagSzIn   = input.string("Normal", "Tag size", options = ["Small", "Normal", "Large"], group = G_C, tooltip = "Text size of the tags.")

int    horizon   = input.int(48, "Horizon (bars)", minval = 5, maxval = 500, group = G_H, tooltip = "Each live pool is watched in back-to-back windows of this many bars. A window counts as crossed if price went beyond the pool before it ended, gaps included, and is filed by side and by the pool's ATR distance from price when it opened. Windows open only while the pool is inside the relevance band, within 8 ATR and young enough to finish before Retire; once open, a window always resolves. Must be at least Swing length below Retire after, or no window can open. The rates count windows, not pools: one pool opens many. Each crossed window also records how many bars it took to cross (time to sweep).")
int    winW      = input.int(3000, "Window (bars)", minval = 500, maxval = 10000, step = 500, group = G_H, tooltip = "Only windows that ended within this many recent bars are counted, so on charts with more history than this the rates don't depend on how much is loaded. At most the latest 50,000 windows are kept.")
int    retireN   = input.int(1000, "Retire after (bars)", minval = 100, maxval = 4400, step = 100, group = G_H, tooltip = "A pool opens no window that would end past this age, and once older than this its tag reads 'no rate'. It stays on the map.")
int    minN      = input.int(30, "Minimum sample (windows)", minval = 10, maxval = 500, group = G_H, tooltip = "Below this many windows a band shows no meter or %: tags read 'n 12 · too few' and the panel shows —.")

int    fillT     = input.int(0, "Contour transparency (%)", minval = 0, maxval = 60, group = G_S, tooltip = "0 = solid fill. Candles always draw on top of the map.")
bool   showLeg   = input.bool(true, "Score legend", group = G_S, tooltip = "Colour key for the contours drawn, as % of the tallest zone within the map's high–low range, plus a grey swept row when swept zones are drawn grey.")
string legPos    = input.string("Middle right", "Legend position", options = ["Middle right", "Middle left", "Top right", "Top left", "Bottom right", "Bottom left"], group = G_S, tooltip = "Where the legend sits on the chart.")

bool   showPanel = input.bool(true, "Show panel", group = G_T, tooltip = "Live settings, past crossing rates by band and side, the tallest live zone, pool counts and the last bar used.")
string panelPos  = input.string("Top right", "Panel position", options = ["Top right", "Top left", "Bottom right", "Bottom left"], group = G_T, tooltip = "Corner of the chart the panel sits in.")
string txtSizeIn = input.string("Small", "Text size", options = ["Tiny", "Small", "Normal"], group = G_T, tooltip = "Size of the panel's main rows. The settings and last-bar rows are small (tiny at Tiny).")

// ───────────────────────────── Constants ─────────────────────────────
const int    POLY_CAP = 100      // TradingView's polyline limit (max_polylines_count)
const int    PT_CAP   = 150000   // most chart points one map may build: keeps the 3D look inside Pine's loop time limit
const int    CHUNK    = 9500     // points per merged 3D polyline (TradingView allows 10,000)
const int    MAX_AGE  = 4900     // pools older than this are dropped, so every stem stays on its wick inside the 5,000-bar buffer
const int    ATR_LEN  = 100
const int    VOL_LEN  = 50
const float  VOL_LO   = 0.5      // swing volume factor clamp
const float  VOL_HI   = 2.5
const float  TOUCH_HI = 2.5      // touch factor cap, reached on the 4th touch
const int    N_BKT    = 6        // distance bands per side: <0.5, 0.5–1, 1–2, 2–3, 3–5, 5–8 ATR
const float  BKT_MAX  = 8.0      // windows further than this (ATR) are not filed
const int    Q_CAP    = 50000    // most windows held in the rolling sample
const int    LEG_ROWS = 15       // legend rows: most contours + the swept row
const float  EPS_SCORE = 0.01     // smallest score that can make a zone
const float  LUM_MID  = 0.1791   // background luminance at which black and white text contrast equally
const float  KNEE     = 0.25     // softness of the bend where the build-up reaches full height
const float  PAD      = 0.06     // clear space kept between neighbouring zones (half-heights)
const float  STAG     = 1.5      // 3D: how far each inner ring's nose sits behind the one outside it (nose radii)
const float  EDGE_B   = 0.5      // lower edge width relative to the upper edge
const float  SH_LOW   = 0.62     // shadow strength on the lowest band
const float  SH_TOP   = 0.14     // shadow strength just under the top contour
const float  CORE_L   = 0.6      // reach of the dark core under each ring (ring gaps)
const float  CORE_A   = 0.32     // strength of the dark core
const float  GR_LEN   = 1.2      // ground shadow depth (ring gaps)
const float  CALL_M   = 0.3      // clearance around a tag (half-heights)
const float  NOSE_Q   = 0.7      // nose softness: 0.5 = ellipse, 1 = pointed; in between, the width grows over several bars
const int    MIN_COL  = 5        // fewest bars a ring needs to draw a smooth outline
const int    MIN_HIT  = 10       // fewest crossed windows before a band shows a time to sweep
const float  TAG_H    = 0.075    // approximate tag body height, share of the map's price range (Normal size)
const float  TAG_W    = 0.13     // approximate tag width, share of the map width (Normal size)
const string MONO     = font.family_monospace

// ───────────────────────────── Types ─────────────────────────────
type Pool
    float price
    int   start     // swing candle
    int   side      // 1 = swing high (buy stops above), -1 = swing low (sell stops below)
    int   touches
    float vol       // volume factor used (0.5–2.5, 1 when not measured)
    float vRaw      // measured swing volume ÷ its average, na when not measured
    int   taken     // bar price crossed it, na while live
    int   wStart    // bar the open stats window started, na when none is open
    int   wCell     // stats cell that window files into

// One pool's zone: its free half-height, peak score, column span and 3D ring spacing
type Zone
    Pool  p
    float sig
    float aMax
    int   xs
    int   xe
    bool  hasE      // closed right end (swept)
    float gap

// ───────────────────────────── State ─────────────────────────────
var array<color>    RAMP_D = array.from(#0B2530, #0E3440, #11444C, #145553, #176656, #1F7A57, #2E8E55, #4AA352, #73B64C, #A3C447, #CFCB45, #F0C93F, #FFD966, #FFF1C2)    // dark backgrounds: deep base, bright top
var array<color>    EDGE_D = array.from(#2A5566, #2F6373, #35737D, #3C8682, #459A84, #54AF85, #68C385, #8AD584, #B2E27F, #D9EB7F, #F5EF8B, #FFE994, #FFF1BD, #FFFDF7)    // dark backgrounds: 3D lit edges
var array<color>    RAMP_L = array.from(#E2F1EF, #C4E5E0, #A6D9D1, #82C7BD, #5DB5A9, #46A692, #2F967B, #3E9A55, #5EA448, #7FAE3C, #A0B233, #C2B52A, #E19E14, #B9700A)    // light backgrounds: pale base, deep top
var array<color>    EDGE_L = array.from(#EFF7F6, #DFF1EE, #CEEAE6, #BAE0DB, #A6D6D0, #99CEC3, #8DC5B6, #95C7A2, #A6CD9A, #B9D294, #CBD58F, #DDD68A, #EECA7E, #D8B078)    // light backgrounds: 3D lit edges
var array<color>    GRY_D  = array.from(#1E2126, #282C31, #33363C, #3D4146, #474B51, #52565C, #5C6067, #666B71, #70757C, #7B8087, #858A92, #8F959C, #9A9FA7, #A4AAB2)    // swept zones, dark backgrounds
var array<color>    GRY_L  = array.from(#C3C8CE, #B8BDC4, #AEB3B9, #A3A8AF, #999EA4, #8E939A, #84898F, #797E85, #6F747A, #646970, #5A5F65, #4F545B, #454A50, #3A3F46)    // swept zones, light backgrounds
var array<string>   BKT_NM = array.from("<0.5", "0.5–1", "1–2", "2–3", "3–5", "5–8")
var array<Pool>     pools  = array.new<Pool>()
var array<polyline> polys  = array.new<polyline>()
var array<label>    labs   = array.new<label>()
var array<int>      rN     = array.new<int>(2 * N_BKT, 0)   // windows per cell in the rolling sample: highs 0–5, lows 6–11
var array<int>      rH     = array.new<int>(2 * N_BKT, 0)   // of those, crossed
var array<int>      qBar   = array.new<int>(Q_CAP, 0)       // rolling sample (ring buffer): bar each window ended
var array<int>      qCell  = array.new<int>(Q_CAP, 0)       // its cell, + 2 × N_BKT when crossed
var array<int>      qK     = array.new<int>(Q_CAP, 0)       // bars it took to cross (horizon when not crossed)
var array<int>      rT     = array.new<int>(2 * N_BKT * horizon, 0)    // crossed windows per cell by bars taken (1..horizon)
var array<int>      qPtr   = array.from(0, 0)               // oldest entry, entries held
var array<float>    oPx    = array.new<float>()             // open windows of dropped pools, run to their end: price
var array<int>      oSide  = array.new<int>()               //   side
var array<int>      oStart = array.new<int>()               //   bar the window opened
var array<int>      oCell  = array.new<int>()               //   stats cell
var bool            panMrg = false

float atr     = ta.rma(ta.tr(true), ATR_LEN)
float phv     = ta.pivothigh(high, pivLen, pivLen)
float plv     = ta.pivotlow(low, pivLen, pivLen)
float volMA   = ta.sma(volume, VOL_LEN)
bool  volOK   = useVol and not na(volume[pivLen]) and volMA[pivLen + 1] > 0    // swing candle against the 50 bars before it
float vRel    = volOK ? volume[pivLen] / volMA[pivLen + 1] : na
float vF      = volOK ? math.max(VOL_LO, math.min(VOL_HI, vRel)) : 1.0
float winLo   = ta.lowest(low, lookback)
float winHi   = ta.highest(high, lookback)
int   x0      = math.max(0, bar_index - lookback + 1)    // map's left edge
float bLo     = winLo - relBand * atr                     // relevance band; na while ATR warms up
float bHi     = winHi + relBand * atr
bool  flat    = look == "Flat contours"
bool  statsOn = chart.is_standard                         // Renko, Kagi, Heikin Ashi and the like synthesise their bars
bool  hzOK    = horizon <= retireN - pivLen               // a pool is at least Swing length old, so a longer horizon could never open a window
float stagK   = flat ? 1.0 : STAG                         // flat look: rings share one nose centre, like contour lines
float R0      = roundK * hPct / 100.0 * lookback * 1.2    // nose length of a full-width ring (bars)

// sRGB channel → linear light, for the WCAG 2 relative luminance of the background (W3C public standard)
f_lin(float v) =>
    float c = v / 255.0
    c <= 0.04045 ? c / 12.92 : math.pow((c + 0.055) / 1.055, 2.4)

color bgc     = chart.bg_color
bool  isLight = 0.2126 * f_lin(color.r(bgc)) + 0.7152 * f_lin(color.g(bgc)) + 0.0722 * f_lin(color.b(bgc)) > LUM_MID
color txt     = isLight ? #1A1F29 : #D7DCE4
color txtDim  = isLight ? #5A6270 : #8E97A6
color boxBg   = isLight ? color.new(#FFFFFF, 5) : color.new(#0A0D13, 8)
color accent  = isLight ? #7A4F00 : #FFC53D
color headBg  = isLight ? #7A4F00 : #FFF1C2
color headTx  = isLight ? #FFF1C2 : #120C00

tSize = switch txtSizeIn
    "Tiny"   => size.tiny
    "Normal" => size.normal
    => size.small
tSub  = txtSizeIn == "Tiny" ? size.tiny : size.small

tagSz = switch tagSzIn
    "Small" => size.small
    "Large" => size.large
    => size.normal

posTbl = switch panelPos
    "Top left"     => position.top_left
    "Bottom right" => position.bottom_right
    "Bottom left"  => position.bottom_left
    => position.top_right

legTbl = switch legPos
    "Middle left"  => position.middle_left
    "Top right"    => position.top_right
    "Top left"     => position.top_left
    "Bottom right" => position.bottom_right
    "Bottom left"  => position.bottom_left
    => position.middle_right

var table pan = table.new(posTbl, 3, 12, bgcolor = boxBg, frame_color = #7D7D7D, frame_width = 1)
var table leg = table.new(legTbl, 2, LEG_ROWS, bgcolor = boxBg, border_width = 1)

// ───────────────────────────── Functions ─────────────────────────────
// Pre-sweep height factor: swing volume × touches. Score = this × the age envelope, × the swept height once
// swept: a unitless score from 0 to 6.25 (2.50 when volume is not measured).
f_raw(Pool p) =>
    p.vol * math.min(TOUCH_HI, 1.0 + 0.5 * (p.touches - 1))

// Age envelope at bar x: a straight build-up from the swing candle that bends into full height at riseLen bars. The
// bend is a parabola over the last 2 × KNEE of the ramp (u from 1 - KNEE to 1 + KNEE), meeting the ramp and the flat
// top with matching slopes, so the height never kinks.
f_env(Pool p, int x) =>
    float u = (1.0 + KNEE) * (x - p.start) / riseLen
    float t = 1.0 + KNEE - u    // ramp left before the top: 2 × KNEE where the bend starts, 0 at full height
    u <= 0.0 ? 0.0 : t >= 2.0 * KNEE ? u : t <= 0.0 ? 1.0 : 1.0 - t * t / (4.0 * KNEE)

// Envelope peak: a live pool is still building or flat, so its peak is now (the last closed bar); a swept pool keeps
// the height it had on its sweep bar, so a zone never grows after price has crossed it
f_envPeak(Pool p) =>
    f_env(p, na(p.taken) ? bar_index : p.taken)

// Distance band of a window (ATR): <0.5, 0.5–1, 1–2, 2–3, 3–5, 5–8; -1 = beyond 8 ATR or no ATR yet, not filed
f_bkt(float d) =>
    na(d) or d >= BKT_MAX ? -1 : d < 0.5 ? 0 : d < 1.0 ? 1 : d < 2.0 ? 2 : d < 3.0 ? 3 : d < 5.0 ? 4 : 5

f_cell(int side, int b) =>
    side == 1 ? b : b + N_BKT

// Add (d = 1) or remove (d = -1) one window's counts: crossing rate and time to sweep
f_count(int cell, bool hit, int k, int d) =>
    if hit
        rH.set(cell, rH.get(cell) + d)
        rT.set(cell * horizon + k - 1, rT.get(cell * horizon + k - 1) + d)
    rN.set(cell, rN.get(cell) + d)

// Drop the oldest window from the rolling sample
f_pop() =>
    int  h0  = qPtr.get(0)
    int  c   = qCell.get(h0)
    bool hit = c >= 2 * N_BKT
    f_count(hit ? c - 2 * N_BKT : c, hit, qK.get(h0), -1)
    qPtr.set(0, (h0 + 1) % Q_CAP)
    qPtr.set(1, qPtr.get(1) - 1)

// File one finished window into the rolling sample (k = bars it took to cross); the oldest makes room once Q_CAP are held
f_file(int cell, bool hit, int k) =>
    if qPtr.get(1) == Q_CAP
        f_pop()
    f_count(cell, hit, k, 1)
    int i = (qPtr.get(0) + qPtr.get(1)) % Q_CAP
    qBar.set(i, bar_index)
    qCell.set(i, hit ? cell + 2 * N_BKT : cell)
    qK.set(i, k)
    qPtr.set(1, qPtr.get(1) + 1)

// Time to sweep: among a cell's crossed windows, the fewest bars within which more than half of them crossed (the
// median; on an even count the upper of the two middle values); -1 below MIN_HIT
f_half(int c) =>
    int out = -1
    if rH.get(c) >= MIN_HIT
        int need = int(rH.get(c) / 2) + 1
        int acc  = 0
        for k = 1 to horizon
            acc += rT.get(c * horizon + k - 1)
            if acc >= need
                out := k
                break
    out

// A new swing merges into the nearest live pool on its side within the tolerance (EQH / EQL); otherwise it starts a
// pool. When the list is full, the live pool furthest beyond the relevance band goes first, then the oldest live pool,
// then, only when no other pool is live, the oldest swept one. The pool just added is never the one dropped. A dropped
// pool's open window is handed over to run to its end, so every window that opens is counted.
f_addPool(float price, int start, int side, float vol, float vr) =>
    float tol  = eqTol * atr
    Pool  best = na
    float bd   = na
    for p in pools
        if na(p.taken) and p.side == side
            float d = math.abs(p.price - price)
            if d <= tol and (na(best) or d < bd)
                best := p
                bd   := d
    if not na(best)
        best.touches := best.touches + 1
        if vol > best.vol
            best.vol  := vol
            best.vRaw := vr
    else
        pools.push(Pool.new(price, start, side, 1, vol, vr))
    if pools.size() > maxPools    // only right after a push, so the last pool is the one just added
        int   iOut = -1
        float dOut = 0.0
        int   iOld = -1
        int   iSw  = -1
        for i = 0 to pools.size() - 2
            Pool p = pools.get(i)
            if na(p.taken)
                float dB = math.max(p.price - bHi, bLo - p.price)    // > 0 beyond the band
                if dB > dOut
                    iOut := i
                    dOut := dB
                if iOld < 0 or p.start < pools.get(iOld).start
                    iOld := i
            else if iSw < 0 or p.taken < pools.get(iSw).taken
                iSw := i
        Pool out = pools.remove(iOut >= 0 ? iOut : iOld >= 0 ? iOld : iSw)
        if not na(out.wStart)
            oPx.push(out.price)
            oSide.push(out.side)
            oStart.push(out.wStart)
            oCell.push(out.wCell)

// Nose length of a zone: R0, or less when the zone is too short to hold full noses
f_r0(int xs, int xe, bool hasE) =>
    math.min(R0, (hasE ? (xe - xs) / 2.0 : xe - xs) / stagK)

// Contour L of a zone is a stadium: f = its share of the zone's half-height, nose length scaled to it so every
// nose has the same shape, set back from the zone's start (and, once swept, its end) so inner rings nest inside
// outer ones like contours. Each nose starts exactly on a bar (its tip) and runs to where the unrounded nose ends, so
// the rings stay evenly spaced and the outline never steps behind the tip. n = the points the outline takes; 0 when the
// ring is above the peak or has fewer than MIN_COL bars to draw on.
f_geo(int xs, int xe, bool hasE, float aMax, float L) =>
    float f    = 1.0 - L / aMax
    float r0   = f_r0(xs, xe, hasE)
    float lead = stagK * r0 * (1.0 - f)
    float R    = math.max(2.0, r0 * f)
    int   xa   = int(math.round(xs + lead))
    int   xb   = hasE ? int(math.round(xe - lead)) : xe
    int   xL   = hasE ? xb - 1 : xe
    int   n    = L < aMax and xL >= xa + MIN_COL - 1 ? 1 + 2 * (xL - xa) + (hasE ? 1 : 0) : 0
    [xa, xb, xL, xs + lead + R - xa, xb - (xe - lead - R), f, n]

f_npts(int xs, int xe, bool hasE, float aMax, float L) =>
    [xa, xb, xL, RL, RR, f, n] = f_geo(xs, xe, hasE, aMax, L)
    n

// Nose profile, 0 at the tip → 1 on the flat run: an elliptical curve softened by NOSE_Q, so the width grows over
// several bars instead of jumping in the first one and leaving a kink behind the tip
f_nose(float s) =>
    float c = math.min(1.0, s)
    math.pow(c * (2.0 - c), NOSE_Q)

// Outline height at bar x as a share of the ring's half-height; a swept pool gets the same nose at its end
f_g(int x, int xa, int xb, float RL, float RR, bool hasE) =>
    float g = f_nose((x - xa) / RL)
    if hasE
        g *= f_nose((xb - x) / RR)
    g

// One zone's ring at level L as a closed outline. Every layer is the same profile scaled, so the lit edge, shadows
// and face all follow one curve. kind 0 = ring widened by eT / eB ring gaps (face or edge), 1 = shadow down to the
// ring outside (Lp), 2 = dark core just under the ring, 3 = ground shadow. No layer reaches past the zone's half-height.
f_loop(Zone s, float L, float Lp, int kind, float eT, float eB, array<chart.point> pts) =>
    [xa, xb, xL, RL, RR, f, n] = f_geo(s.xs, s.xe, s.hasE, s.aMax, L)
    if n > 0
        float pr = s.p.price
        float w  = s.sig * f
        float d  = kind == 1 ? s.sig * (L - Lp) / s.aMax : kind == 2 ? CORE_L * s.gap : kind == 3 ? GR_LEN * s.gap : 0.0
        float wT = math.min(s.sig, w + eT * s.gap)
        float wB = math.min(s.sig, w + (kind == 0 ? eB * s.gap : d))
        pts.push(chart.point.from_index(xa, pr))
        for x = xa + 1 to xL
            pts.push(chart.point.from_index(x, pr + wT * f_g(x, xa, xb, RL, RR, s.hasE)))
        if s.hasE
            pts.push(chart.point.from_index(xb, pr))
        for x = xL to xa + 1
            pts.push(chart.point.from_index(x, pr - wB * f_g(x, xa, xb, RL, RR, s.hasE)))
    n > 0

// Close the merged polygon by retracing the zero-width bridges, then draw it
f_flush(array<chart.point> pts, array<chart.point> anc, color fc) =>
    if pts.size() >= 3
        if anc.size() > 1
            for k = anc.size() - 2 to 0
                pts.push(anc.get(k))
        polys.push(polyline.new(pts, false, true, line_color = na, fill_color = fc))

// 3D: every zone's ring on one contour becomes one filled polyline (chunked under the point limit)
f_layer(array<Zone> zns, float L, float Lp, int kind, float eT, float eB, color fc) =>
    array<chart.point> pts = array.new<chart.point>()
    array<chart.point> anc = array.new<chart.point>()
    for s in zns
        if pts.size() > 0 and pts.size() + 2 * (s.xe - s.xs) + 8 > CHUNK    // reserve: the most points one zone can add, plus its bridge
            f_flush(pts, anc, fc)
            pts := array.new<chart.point>()
            anc := array.new<chart.point>()
        int n0 = pts.size()
        if f_loop(s, L, Lp, kind, eT, eB, pts)
            chart.point a0 = pts.get(n0)
            pts.push(a0)
            anc.push(a0)
    f_flush(pts, anc, fc)

// Polylines and points one 3D layer at level L will take: f_layer's own chunking, without building anything
f_layerCost(array<Zone> zns, float L) =>
    int nP  = 0
    int nPt = 0
    int acc = 0
    int nA  = 0
    for s in zns
        if acc > 0 and acc + 2 * (s.xe - s.xs) + 8 > CHUNK    // the same reservation and guard as f_layer
            nP  += 1
            nPt += acc + nA - 1
            acc := 0
            nA  := 0
        int n = f_npts(s.xs, s.xe, s.hasE, s.aMax, L)
        if n > 0
            acc += n + 1
            nA  += 1
    if acc > 0
        nP  += 1
        nPt += acc + nA - 1
    [nP, nPt]

// The most contours that fit the polyline budget and the point cap: evenly spaced, the lowest and the top kept whenever two or more fit
f_pick(array<int> cP, array<int> cPt, int budget) =>
    array<int> sel = array.new<int>()
    for k = nLevels to 1
        array<int> s = array.new<int>()
        int p = 0
        int q = 0
        for j = 0 to k - 1
            int i = k == 1 ? 0 : int(math.round(j * (nLevels - 1) / float(k - 1)))
            s.push(i)
            p += cP.get(i)
            q += cPt.get(i)
        if p <= budget and q <= PT_CAP
            sel := s
            break
    sel

// Rounded % → 10-block meter: 0% shows no block, a full meter needs 99.5% or more
f_meter(int pI) =>
    int    nf = pI <= 0 ? 0 : pI >= 100 ? 10 : math.max(1, math.min(9, int(math.round(pI / 10.0))))
    string s  = ""
    for k = 1 to 10
        s += k <= nf ? "▰" : "▱"
    s

// Price at tick precision, or in full when it is off the tick grid (back-adjusted contracts)
f_px(float v) =>
    float t = v / syminfo.mintick
    math.abs(t - math.round(t)) < 1e-6 ? str.tostring(v, format.mintick) : str.tostring(v)

// Rounded crossing rate; "<1%" when some windows crossed but fewer than 0.5%, ">99%" when not all crossed but 99.5% or more did
f_pctTxt(int h, int n) =>
    int pI = int(math.round(100.0 * h / n))
    pI == 0 and h > 0 ? "<1%" : pI == 100 and h < n ? ">99%" : str.tostring(pI) + "%"

// Signed ATR distance, the same rule on tags and panel
f_dist(float d) =>
    d > 0 ? "▲ " + str.tostring(d, "0.0") + " ATR" : d < 0 ? "▼ " + str.tostring(-d, "0.0") + " ATR" : "= 0.0 ATR"

f_volTxt(Pool p) =>
    not useVol ? "off" : na(p.vRaw) ? "n/a, counted as average" : str.tostring(p.vRaw, "0.0") + "× avg" + (p.vRaw > VOL_HI ? " (capped at 2.5×)" : p.vRaw < VOL_LO ? " (floored at 0.5×)" : "")

// One panel cell: % and windows over bands b0..b1 on one side, — below the minimum sample
f_rateTxt(int side, int b0, int b1) =>
    int n = 0
    int h = 0
    for b = b0 to b1
        int c = f_cell(side, b)
        n += rN.get(c)
        h += rH.get(c)
    n == 0 ? "—" : n < minN ? "— · n " + str.tostring(n) : f_pctTxt(h, n) + " · n " + str.tostring(n)

f_panRow(int r, string lab, int b0, int b1) =>
    table.cell(pan, 0, r, lab, text_color = txtDim, text_size = tSize, text_halign = text.align_left, text_font_family = MONO)
    table.cell(pan, 1, r, f_rateTxt(1, b0, b1), text_color = txt, text_size = tSize, text_halign = text.align_right, text_font_family = MONO)
    table.cell(pan, 2, r, f_rateTxt(-1, b0, b1), text_color = txt, text_size = tSize, text_halign = text.align_right, text_font_family = MONO)

// ───────────────────────────── Pool tracking (closed bars only) ─────────────────────────────
if barstate.isconfirmed
    // Forget swept pools whose zone has left the map, and pools too old for a stem to reach their swing candle
    if pools.size() > 0
        for i = pools.size() - 1 to 0
            Pool p = pools.get(i)
            if (not na(p.taken) and p.taken + capLen < x0) or bar_index - p.start > MAX_AGE
                pools.remove(i)

    if not na(phv)
        f_addPool(phv, bar_index - pivLen, 1, vF, vRel)
    if not na(plv)
        f_addPool(plv, bar_index - pivLen, -1, vF, vRel)

    // Sweeps, and the windows behind the history stats. A live pool watches back-to-back windows of `horizon`
    // bars, each filed by side and by its ATR band when it opened. A window opens only for a pool inside the relevance
    // band, within 8 ATR and young enough to finish it before Retire (bLo / bHi are na while ATR warms up, so none opens
    // then). Once open, a window always resolves, crossed or not, so no outcome is cut short.
    for p in pools
        if na(p.taken)
            if (p.side == 1 and high > p.price) or (p.side == -1 and low < p.price)
                p.taken := bar_index
                if not na(p.wStart)
                    f_file(p.wCell, true, bar_index - p.wStart)
                    p.wStart := na
            else
                if not na(p.wStart) and bar_index - p.wStart >= horizon
                    f_file(p.wCell, false, horizon)
                    p.wStart := na
                if na(p.wStart) and statsOn and bar_index - p.start + horizon <= retireN and p.price >= bLo and p.price <= bHi
                    int b = f_bkt(math.abs(p.price - close) / atr)
                    if b >= 0
                        p.wStart := bar_index
                        p.wCell  := f_cell(p.side, b)
    // Windows handed over by dropped pools: same crossing rule, same end
    if oPx.size() > 0
        for i = oPx.size() - 1 to 0
            float px  = oPx.get(i)
            int   ws  = oStart.get(i)
            bool  hit = (oSide.get(i) == 1 and high > px) or (oSide.get(i) == -1 and low < px)
            if hit or bar_index - ws >= horizon
                f_file(oCell.get(i), hit, hit ? bar_index - ws : horizon)
                oPx.remove(i)
                oSide.remove(i)
                oStart.remove(i)
                oCell.remove(i)

    // Rolling sample: keep only windows that ended within the last `winW` bars
    while qPtr.get(1) > 0 and qBar.get(qPtr.get(0)) <= bar_index - winW
        f_pop()

// ───────────────────────────── Map build (on bar close) ─────────────────────────────
if barstate.islastconfirmedhistory or (barstate.isrealtime and barstate.isconfirmed)
    for pl in polys
        polyline.delete(pl)
    polys.clear()
    for lb in labs
        label.delete(lb)
    labs.clear()

    float rng   = winHi - winLo
    float sig   = hPct / 100.0 * rng
    float dG    = (topPct - lowPct) / 100.0 / (nLevels - 1)                // contour spacing, share of the peak
    array<color> RMP = isLight ? RAMP_L : RAMP_D    // the palette follows the chart background
    array<color> EDG = isLight ? EDGE_L : EDGE_D
    float rTop  = RMP.size() - 1
    int   xE    = bar_index + extR
    int   nLay0 = flat ? 1 : (groundOn ? 1 : 0) + (edgeOn ? 1 : 0) + 1    // outlines per zone on the lowest contour (3D: layers), for the point check
    int   nCol  = (edgeOn ? 1 : 0) + 1                                    // 3D coloured layers per contour (edge, face)
    array<color> GRY = isLight ? GRY_L : GRY_D

    // Pools in view: everything inside the relevance band (swept pools leave the list once off the map)
    array<Pool> vis    = array.new<Pool>()
    int         nSwept = 0
    for p in pools
        if p.price >= bLo and p.price <= bHi
            vis.push(p)
            if not na(p.taken)
                nSwept += 1

    // Each pool is its own zone. Pools claim their space in order of pre-sweep height, so a sweep lowers a zone but
    // never removes it. Zones that share bars are trimmed to half the gap between them, so they never touch and close
    // levels stay visible side by side. A pool is hidden only when it sits within the min zone gap of a taller one, its
    // peak stays below the lowest contour, its outer ring has too few bars for a smooth outline, or it falls past what
    // a single contour can draw. A zone too short for full noses is scaled down whole, so it keeps the same shape.
    array<Zone> zns  = array.new<Zone>()    // tallest pre-sweep height first
    float      fTop = 0.0
    if sig > 0
        array<int>   sArr = array.new<int>()
        array<int>   eArr = array.new<int>()
        array<float> rkA  = array.new<float>()
        array<float> pkA  = array.new<float>()
        float        pkAll = 0.0
        float        pkMap = 0.0
        for p in vis
            int   xs = math.max(p.start, x0)
            int   xe = na(p.taken) ? xE : math.min(xE, p.taken + capLen)
            float rk = f_raw(p) * f_envPeak(p)
            sArr.push(xs)
            eArr.push(xe)
            rkA.push(rk)
            float pk = na(p.taken) ? rk : rk * erode
            pkA.push(pk)
            pkAll := math.max(pkAll, pk)
            if p.price >= winLo and p.price <= winHi
                pkMap := math.max(pkMap, pk)
        // Colours and contours are scaled to the tallest zone inside the map's high–low range (what is on screen), so
        // a big pool far off-screen doesn't wash the visible map out; taller pools further out top out at the top colour
        float      gate  = math.max(EPS_SCORE, (pkMap > 0 ? pkMap : pkAll) * lowPct / 100.0)
        float      fMap  = 0.0
        array<int> ord   = rkA.sort_indices(order.descending)
        array<int> keep  = array.new<int>()
        int        ptAcc = 0
        for k in ord
            // Every kept zone's lowest contour must fit: one polyline per flat ring, and the point cap in either look
            int  pt = (2 * (eArr.get(k) - sArr.get(k)) + 5) * nLay0
            bool ok = pkA.get(k) > gate and (not flat or keep.size() < POLY_CAP - (showCall ? nCall : 0)) and ptAcc + pt <= PT_CAP
            if ok    // its outer ring needs room for a smooth outline (checked at the gate, which is never below the final lowest contour)
                ok := f_npts(sArr.get(k), eArr.get(k), not na(vis.get(k).taken) and eArr.get(k) < xE, pkA.get(k), gate) > 0
            if ok
                for m in keep
                    if sArr.get(m) <= eArr.get(k) and sArr.get(k) <= eArr.get(m) and math.abs(vis.get(m).price - vis.get(k).price) < gapK * sig
                        ok := false
                        break
            if ok
                keep.push(k)
                ptAcc += pt
                fTop  := math.max(fTop, pkA.get(k))
                if vis.get(k).price >= winLo and vis.get(k).price <= winHi
                    fMap := math.max(fMap, pkA.get(k))
        fTop := fMap > 0 ? fMap : fTop
        for k in keep
            Pool  p = vis.get(k)
            float g = 1e18
            for m in keep
                if m != k and sArr.get(m) <= eArr.get(k) and sArr.get(k) <= eArr.get(m)
                    g := math.min(g, math.abs(vis.get(m).price - p.price))
            bool  hE = not na(p.taken) and eArr.get(k) < xE
            float sI = math.min(sig * f_r0(sArr.get(k), eArr.get(k), hE) / R0, 0.5 * g - PAD * sig)
            zns.push(Zone.new(p, sI, pkA.get(k), sArr.get(k), eArr.get(k), hE, sI * fTop / pkA.get(k) * dG))

    // Contours: price each one in polylines and points, then draw the most that fit. Tag stems are reserved first,
    // one polyline per tag. Only zones whose swing candle is on the map can be tagged, so a stem never runs off the map.
    array<float> lv      = array.new<float>()
    array<int>   cix     = array.new<int>()
    array<int>   cP      = array.new<int>()
    array<int>   sel     = array.new<int>()
    int          nTagOK  = 0
    for s in zns
        if s.p.start >= x0
            nTagOK += 1
    int          nTag    = showCall ? math.min(nCall, nTagOK) : 0
    int          budget  = POLY_CAP - nTag
    int          nPoly   = 0
    int          dropped = 0
    // 3D: shadows merge every zone into one polyline per layer; coloured layers draw live and grey swept zones apart
    array<Zone> zL = array.new<Zone>()
    array<Zone> zS = array.new<Zone>()
    for s in zns
        if sweptGry and not na(s.p.taken)
            zS.push(s)
        else
            zL.push(s)
    if zns.size() > 0
        array<int> cPt  = array.new<int>()
        int        need = 0
        for i = 0 to nLevels - 1
            float L = fTop * (lowPct / 100.0 + i * dG)
            lv.push(L)
            cix.push(int(math.round(rTop * i / (nLevels - 1))))
            if flat
                int c = 0
                int q = 0
                for s in zns
                    int n = f_npts(s.xs, s.xe, s.hasE, s.aMax, L)
                    if n > 0
                        c += 1
                        q += n
                cP.push(c)
                cPt.push(q)
            else
                [cA, qA] = f_layerCost(zns, L)
                [cL, qL] = f_layerCost(zL, L)
                [cW, qW] = f_layerCost(zS, L)
                int nSh = i == 0 ? (groundOn ? 1 : 0) : (shadeOn ? 2 : 0)
                cP.push(nSh * cA + nCol * (cL + cW))
                cPt.push(nSh * qA + nCol * (qL + qW))
            need += cP.last()
        sel := f_pick(cP, cPt, budget)
        if not flat and sel.size() > 1 and sel.size() < nLevels
            // 3D on a thinned ladder: edges, cores and ground shadows scale with the ring spacing actually drawn
            for s in zns
                s.gap := s.gap * (nLevels - 1) / float(sel.size() - 1)

        if flat
            // Flat contours: every ring of every zone is its own polyline, lowest contour first. Straight segments: the
            // nose profile is already smooth bar to bar, and curve smoothing would round each sharp tip into a bulge
            for i in sel
                color fc = color.new(RMP.get(cix.get(i)), fillT)
                color fg = color.new(GRY.get(cix.get(i)), fillT)
                for s in zns
                    array<chart.point> pts = array.new<chart.point>()
                    if f_loop(s, lv.get(i), 0.0, 0, 0.0, 0.0, pts)
                        polys.push(polyline.new(pts, false, true, line_color = na, fill_color = sweptGry and not na(s.p.taken) ? fg : fc))
        else
            // 3D contours, contour by contour: shadow cast on the slope below → dark core → lit edge → face
            float op    = 1.0 - fillT / 100.0
            int   grT   = int(math.round(100.0 - (isLight ? 15.0 : 45.0) * op))
            int   coreT = int(math.round(100.0 - 100.0 * shadeK * CORE_A * op))
            for [j, i] in sel
                float L  = lv.get(i)
                int   ci = cix.get(i)
                if i == 0
                    if groundOn
                        f_layer(zns, L, L, 3, 0.0, 0.0, color.new(#000000, grT))
                else if shadeOn
                    float Lp = lv.get(sel.get(j - 1))
                    float aL = shadeK * (SH_LOW - (SH_LOW - SH_TOP) * (i - 1) / float(nLevels - 2)) * op
                    f_layer(zns, L, Lp, 1, 0.0, 0.0, color.new(#000000, int(math.round(100.0 - 100.0 * aL))))
                    f_layer(zns, L, Lp, 2, 0.0, 0.0, color.new(#000000, coreT))
                if edgeOn
                    f_layer(zL, L, L, 0, edgeW, edgeW * EDGE_B, color.new(EDG.get(ci), fillT))
                    f_layer(zS, L, L, 0, edgeW, edgeW * EDGE_B, color.new(GRY.get(isLight ? math.max(0, ci - 2) : math.min(GRY.size() - 1, ci + 2)), fillT))
                f_layer(zL, L, L, 0, 0.0, 0.0, color.new(RMP.get(ci), fillT))
                f_layer(zS, L, L, 0, 0.0, 0.0, color.new(GRY.get(ci), fillT))
        nPoly   := polys.size()
        dropped := need - nPoly

    // Tallest live zone (live zones are ordered by their current height)
    Zone s1 = na
    for s in zns
        if na(s.p.taken)
            s1 := s
            break

    // Callouts: live zones first, then swept, each tallest first. A tag sits beyond its own zone and is
    // pushed past any zone or earlier tag in its way (one sweep outwards in price order clears them all). The dotted
    // stem is a polyline drawn after the map, so it runs over the zones to the wick.
    if nTag > 0
        float tagK  = tagSzIn == "Small" ? 0.8 : tagSzIn == "Large" ? 1.3 : 1.0
        float tagH  = TAG_H * tagK * rng
        float tagW  = TAG_W * tagK * lookback
        float m     = CALL_M * sig
        array<int>   tX  = array.new<int>()     // tags placed so far: bar, body bottom, body top
        array<float> tLo = array.new<float>()
        array<float> tHi = array.new<float>()
        for pass = 0 to 1
            for [si, s] in zns
                bool live = na(s.p.taken)
                if tX.size() < nTag and (pass == 0) == live and s.p.start >= x0
                    Pool  p  = s.p
                    int   x  = p.start
                    bool  up = p.side == 1
                    float y  = up ? p.price + s.sig + m : p.price - s.sig - m
                    array<float> oLo = array.new<float>()
                    array<float> oHi = array.new<float>()
                    for o in zns
                        if o.xs <= x + tagW / 2 and x - tagW / 2 <= o.xe
                            oLo.push(o.p.price - o.sig - m)
                            oHi.push(o.p.price + o.sig + m)
                    for [j, tx] in tX
                        if math.abs(tx - x) < tagW
                            oLo.push(tLo.get(j) - m)
                            oHi.push(tHi.get(j) + m)
                    array<int> ord = up ? oLo.sort_indices(order.ascending) : oHi.sort_indices(order.descending)
                    for j in ord
                        if up and y < oHi.get(j) and y + tagH > oLo.get(j)
                            y := oHi.get(j)
                        else if not up and y > oLo.get(j) and y - tagH < oHi.get(j)
                            y := oLo.get(j)
                    tX.push(x)
                    tLo.push(up ? y : y - tagH)
                    tHi.push(up ? y + tagH : y)

                    color  col  = live ? (isLight ? txt : EDG.get(math.max(6, math.min(EDG.size() - 1, int(math.round(rTop * s.aMax / fTop)))))) : txtDim
                    string nm   = up ? (p.touches > 1 ? "EQH×" + str.tostring(p.touches) : "HIGH") : (p.touches > 1 ? "EQL×" + str.tostring(p.touches) : "LOW")
                    string px   = f_px(p.price)
                    int    age  = bar_index - p.start
                    int    rank = 1    // ties go to the zone placed first, so no two tags share a rank
                    for [oi, o] in zns
                        if o.aMax > s.aMax or (o.aMax == s.aMax and oi < si)
                            rank += 1
                    float  scMax = (useVol and not na(p.vRaw) ? VOL_HI : 1.0) * TOUCH_HI    // top of the score scale on this symbol: 6.25 with volume measured, 2.50 without
                    string sSc  = "Score " + str.tostring(s.aMax, "0.00") + " of " + str.tostring(scMax, "0.00") + " max"
                    string sRk  = " · #" + str.tostring(rank) + " of " + str.tostring(zns.size()) + " drawn"
                    string l2   = ""
                    string tip  = nm + " " + px + (p.touches > 1 ? " · " + str.tostring(p.touches) + " touches" : "")
                    if live
                        float dA = (p.price - close) / atr
                        int   b  = f_bkt(math.abs(dA))
                        if not statsOn
                            l2  := "no rate · non-standard chart"
                            tip += "\nNo rate: stats are off on non-standard chart types"
                        else if not hzOK
                            l2  := "no rate · horizon too long"
                            tip += "\nNo rate: Horizon must be at least Swing length below Retire after, or no window can open"
                        else if age > retireN
                            l2  := "no rate · >" + str.tostring(retireN, "#,###") + "b"
                            tip += "\nNo rate: pools older than " + str.tostring(retireN, "#,###") + " bars are retired from the stats"
                        else if b < 0
                            l2  := "beyond " + str.tostring(BKT_MAX, "0") + " ATR"
                            tip += "\nNo rate: pools more than " + str.tostring(BKT_MAX, "0") + " ATR away are not filed"
                        else
                            int c  = f_cell(p.side, b)
                            int n  = rN.get(c)
                            int pI = n > 0 ? int(math.round(100.0 * rH.get(c) / n)) : 0
                            string pT = n > 0 ? f_pctTxt(rH.get(c), n) : ""
                            l2  := n == 0 ? "no windows yet" : n < minN ? "n " + str.tostring(n) + " · too few" : f_meter(pI) + " " + pT
                            int    kH = n >= minN ? f_half(c) : -1
                            string sT = kH > 0 ? "\nWhen crossed: half within " + str.tostring(kH) + (kH == 1 ? " bar" : " bars") : ""
                            tip += "\n" + (n == 0 ? "No windows yet" : n < minN ? "Too few windows: " + str.tostring(n) + " (minimum " + str.tostring(minN) + ")" : "Crossed within " + str.tostring(horizon) + " bars in " + pT + " of " + str.tostring(n) + " windows") + sT + "\n" + BKT_NM.get(b) + " ATR band · " + (up ? "highs" : "lows") + " · windows that ended in the last " + str.tostring(winW, "#,###") + " bars"
                        tip += "\nDistance " + f_dist(dA) + "\n" + sSc + sRk
                    else
                        int    ago  = bar_index - p.taken
                        string sAgo = ago == 0 ? "this bar" : ago == 1 ? "1 bar ago" : str.tostring(ago) + " bars ago"
                        l2  := "SWEPT " + sAgo
                        tip += "\nSwept " + sAgo + ": price crossed the level\n" + sSc + " (swept ×" + str.tostring(erode, "0.00") + ")" + sRk
                    tip += "\nSwing volume " + f_volTxt(p) + " · age " + str.tostring(age) + " bars"

                    polys.push(polyline.new(array.from(chart.point.from_index(x, p.price), chart.point.from_index(x, y)), line_color = color.new(isLight ? txt : col, 15), line_style = line.style_dotted))
                    labs.push(label.new(x, y, nm + "  " + px + "\n" + l2, color = boxBg, style = up ? label.style_label_down : label.style_label_up, textcolor = col, size = tagSz, textalign = text.align_left, text_font_family = MONO, tooltip = tip))

    // Legend: drawn contours only, top contour first, labelled as % of the reference peak
    if showLeg
        table.clear(leg, 0, 0, 1, LEG_ROWS - 1)
        int row = 0
        if sel.size() > 0
            for j = sel.size() - 1 to 0
                int i = sel.get(j)
                if cP.get(i) > 0
                    table.cell(leg, 0, row, "   ", bgcolor = color.new(RMP.get(cix.get(i)), fillT), text_size = size.small)
                    table.cell(leg, 1, row, str.tostring(lowPct + i * (topPct - lowPct) / (nLevels - 1), "0") + "%", text_color = txtDim, text_size = size.small, text_halign = text.align_left, text_font_family = MONO)
                    row += 1
        if zS.size() > 0
            table.cell(leg, 0, row, "   ", bgcolor = color.new(GRY.get(5), fillT), text_size = size.small)
            table.cell(leg, 1, row, "swept", text_color = txtDim, text_size = size.small, text_halign = text.align_left, text_font_family = MONO)

    // Panel: fixed layout, every visible cell rewritten on each build, merges made once
    if showPanel
        string tf   = timeframe.isminutes ? timeframe.period + "m" : timeframe.period
        string set1 = syminfo.ticker + " · " + tf + " · swing " + str.tostring(pivLen) + " · map " + str.tostring(lookback) + "b +" + str.tostring(extR) + " · contours " + str.tostring(sel.size()) + " of " + str.tostring(nLevels) + " (" + str.tostring(lowPct, "0") + "–" + str.tostring(topPct, "0") + "%)"
        string set2 = "height " + str.tostring(hPct, "0.0") + "% · gap " + str.tostring(gapK, "0.00") + " · rise " + str.tostring(riseLen) + "b · nose " + str.tostring(roundK, "0.0") + " · " + (flat ? "flat" : "3D · edges " + (edgeOn ? str.tostring(edgeW, "0.00") : "off") + " · shade " + (shadeOn ? str.tostring(shadeK, "0.00") : "off") + " · ground " + (groundOn ? "on" : "off")) + " · eq " + str.tostring(eqTol, "0.00") + " ATR · pools " + str.tostring(maxPools) + " · vol " + (useVol ? (na(volMA) or volMA <= 0 ? "on · no data" : "on") : "off")
        string set3 = "horizon " + str.tostring(horizon) + "b · window " + str.tostring(winW, "#,###") + "b · retire " + str.tostring(retireN, "#,###") + "b · min n " + str.tostring(minN) + " · band " + str.tostring(relBand, "0.0") + " ATR · swept ×" + str.tostring(erode, "0.00") + " / " + str.tostring(capLen) + "b" + (sweptGry ? " grey" : "")
        table.cell(pan, 0, 0, "LIQUIDITY CONTOUR MAP", bgcolor = headBg, text_color = headTx, text_size = tSize, text_halign = text.align_left, text_font_family = MONO)
        table.cell(pan, 2, 0, na(atr) or na(winLo) ? "WARMING UP " + str.tostring(bar_index + 1) + "/" + str.tostring(math.max(ATR_LEN, lookback)) : str.tostring(zns.size()) + (zns.size() == 1 ? " ZONE" : " ZONES"), bgcolor = headBg, text_color = headTx, text_size = tSize, text_halign = text.align_right, text_font_family = MONO)
        table.cell(pan, 0, 1, set1, text_color = txtDim, text_size = tSub, text_halign = text.align_left, text_font_family = MONO)
        table.cell(pan, 0, 2, set2, text_color = txtDim, text_size = tSub, text_halign = text.align_left, text_font_family = MONO)
        table.cell(pan, 0, 3, set3, text_color = txtDim, text_size = tSub, text_halign = text.align_left, text_font_family = MONO)
        table.cell(pan, 0, 4, not statsOn ? "STATS OFF · non-standard chart" : not hzOK ? "STATS OFF · horizon > retire − swing" : "Crossed ≤" + str.tostring(horizon) + " bars", text_color = statsOn and hzOK ? txtDim : accent, text_size = tSize, text_halign = text.align_left, text_font_family = MONO)
        table.cell(pan, 1, 4, statsOn and hzOK ? "HIGHS" : "", text_color = txtDim, text_size = tSize, text_halign = text.align_right, text_font_family = MONO)
        table.cell(pan, 2, 4, statsOn and hzOK ? "LOWS" : "", text_color = txtDim, text_size = tSize, text_halign = text.align_right, text_font_family = MONO)
        f_panRow(5, "< 1 ATR", 0, 1)
        f_panRow(6, "1–3 ATR", 2, 3)
        f_panRow(7, "3–5 ATR", 4, 4)
        f_panRow(8, "5–8 ATR", 5, 5)
        table.cell(pan, 0, 9, "Tallest live zone", text_color = txtDim, text_size = tSize, text_halign = text.align_left, text_font_family = MONO)
        table.cell(pan, 1, 9, na(s1) ? "—" : f_px(s1.p.price) + " · score " + str.tostring(s1.aMax, "0.00") + " · " + f_dist((s1.p.price - close) / atr), text_color = txt, text_size = tSize, text_halign = text.align_right, text_font_family = MONO)
        table.cell(pan, 0, 10, "Pools live · swept · polylines", text_color = txtDim, text_size = tSize, text_halign = text.align_left, text_font_family = MONO)
        table.cell(pan, 1, 10, str.tostring(vis.size() - nSwept) + " · " + str.tostring(nSwept) + " · " + str.tostring(nPoly) + "/" + str.tostring(budget) + (dropped > 0 ? " · " + str.tostring(dropped) + " short" : ""), text_color = dropped > 0 ? accent : txt, text_size = tSize, text_halign = text.align_right, text_font_family = MONO)
        table.cell(pan, 0, 11, "Last bar " + str.format_time(na(time_close) ? time : time_close, "yyyy-MM-dd HH:mm Z", syminfo.timezone), text_color = txtDim, text_size = tSub, text_halign = text.align_left, text_font_family = MONO)
        table.cell(pan, 2, 11, str.tostring(bar_index + 1, "#,###") + (bar_index == 0 ? " bar" : " bars"), text_color = txtDim, text_size = tSub, text_halign = text.align_right, text_font_family = MONO)
        if not panMrg
            table.merge_cells(pan, 0, 0, 1, 0)
            table.merge_cells(pan, 0, 1, 2, 1)
            table.merge_cells(pan, 0, 2, 2, 2)
            table.merge_cells(pan, 0, 3, 2, 3)
            table.merge_cells(pan, 1, 9, 2, 9)
            table.merge_cells(pan, 1, 10, 2, 10)
            table.merge_cells(pan, 0, 11, 1, 11)
            panMrg := true